﻿using System;
using System.Collections.Generic;
using System.Linq;

using mvcForum.Core;
using mvcForum.Core.Interfaces.AddOns;
using mvcForum.Core.Interfaces.Search;
using mvcForum.Core.Search;

using mvcForum.SearchProvider.Lucene.Indexes;

using Lucene.Net.Analysis;
using Lucene.Net.Analysis.Standard;
using Lucene.Net.Documents;
using Lucene.Net.Index;
using Lucene.Net.QueryParsers;
using Lucene.Net.Search;
using Lucene.Net.Store;
using Lucene.Net.Util;

using Directory = Lucene.Net.Store.Directory;
using Version = Lucene.Net.Util.Version;
using mvcForum.Core.Interfaces.Services;

namespace mvcForum.SearchProvider.Lucene {

	// TODO: configuration of this provider... plug-in somehow?!?!?!

	public class Indexer : IIndexer, ISearcher, IDisposable, ISearchAddOn {
		private readonly IDirectoryResolver dirResolver;
		private readonly LuceneConfiguration config;
		private readonly ILogger privateLogger;
		private readonly String[] specialLuceneCharacters = { @"\", "+", "-", "&&", "||", "!", "(", ")", "{", "}", "[", "]", "^", "\"", "~", "*", "?", ":" };

		private Directory privateDirectory = null;
		private Analyzer privateAnalyzer = null;
		private Boolean disposed = false;
		private Boolean disposing = false;
		private Boolean initialized = false;
		private static Object writerLock = new Object();

		// Special Lucene characters: "http://lucene.apache.org/java/2_4_0/queryparsersyntax.html#Escaping Special Characters"
		private void Log(EventType type, String message) {
			if (this.privateLogger != null) {
				this.privateLogger.Log(type, message);
			}
		}

		private static class FieldNames {
			public const String PostId = "PostId";
			public const String TopicId = "TopicId";
			public const String ForumId = "ForumId";
			public const String Title = "Title";
			public const String Body = "Body";
			public const String Posted = "Posted";
			public const String Author = "Author";
		}

		public Indexer(IDirectoryResolver resolver, IAddOnConfiguration<Indexer> config, ILogger logger) {
			this.dirResolver = resolver;
			this.config = (LuceneConfiguration)config;
			this.privateLogger = logger;
		}

		//public void SetLog(ILogger logger) {
		//    this.privateLogger = logger;
		//}

		public IEnumerable<SearchResult> Search(String query, IList<Int32> forums) {
			List<SearchResult> result = new List<SearchResult>();
			if (String.IsNullOrWhiteSpace(query)) {
				return result;
			}

			// Let's make sure we've escaped any special chars in the query!
			foreach (String chr in specialLuceneCharacters) {
				if (query.Contains(chr)) {
					query = query.Replace(chr, @"\" + chr);
				}
			}

			QueryParser parser = new QueryParser(Version.LUCENE_29, FieldNames.Body, this.Analyzer);
			parser.DefaultOperator = QueryParser.Operator.AND;

			// Create the query based on the text query.
			Query bodyQuery = parser.Parse(query);

			// Is the query empty?
			if (String.IsNullOrWhiteSpace(bodyQuery.ToString())) {
				// No results found then!
				return result;
			}

			// Let's build the real query
			String queryStringMerged = String.Format("({0}) OR ({1})",
													 bodyQuery,
													 bodyQuery.ToString().Replace("Body", "Title"));

			// Let's create the real query
			Query textQuery = parser.Parse(queryStringMerged);

			BooleanQuery forumQuery = new BooleanQuery();
			foreach (Int32 forumId in forums) {
				forumQuery.Add(new TermQuery(new Term(FieldNames.ForumId, NumericUtils.IntToPrefixCoded(forumId))), Occur.SHOULD);
			}

			BooleanQuery finalQuery = new BooleanQuery();
			finalQuery.Add(textQuery, Occur.MUST);
			finalQuery.Add(forumQuery, Occur.MUST);

			//using (IndexWriter writer = this.GetWriter()) {
			using (IndexSearcher searcher = new IndexSearcher(this.Directory, true)) {
				// TOOD: max ?!?!?!?
				Int32 max = 300;
				TopDocs resultDocs = searcher.Search(finalQuery, max);
				ScoreDoc[] hits = resultDocs.ScoreDocs;
				Int32 length = hits.Length;

				//IEnumerable<Document> docs = hits.Select(x => sch.Doc(x.doc));
				//result = docs.Select(x => new SearchResult { Title = x.Get(FieldNames.Title), Id = NumericUtils.PrefixCodedToInt(x.Get(FieldNames.PostId))/*, Posted = DateTools.StringToDate(x.Get(FieldNames.Posted))*/ });

				Int32 resultsAdded = 0;
				for (Int32 index = 0; index < length && resultsAdded < max; index++) {
					Document hit = searcher.Doc(hits[index].Doc);
					result.Add(new SearchResult { TopicId = NumericUtils.PrefixCodedToInt(hit.Get(FieldNames.TopicId)), PostId = NumericUtils.PrefixCodedToInt(hit.Get(FieldNames.PostId)), Title = hit.Get(FieldNames.Title), Score = resultDocs.ScoreDocs[index].Score });
					resultsAdded++;
				}
				searcher.Close();
			}
			//    writer.Close();
			//}

			return result.OrderByDescending(x => x.Score);
		}

		/// <summary>
		/// Clear the index, delete all indexed content.
		/// </summary>
		public void Clear() {
			// Do we have an index?
			if (IndexReader.IndexExists(this.Directory)) {
				// Yes, let's get a writer!
				using (IndexWriter writer = this.GetWriter()) {
					// Delete all documents from the index!
					writer.DeleteAll();
					// Done, close the writer!
					writer.Close();
				}
			}
		}

		#region Remove methods
		/// <summary>
		/// Remove the documents of the given forum.
		/// </summary>
		/// <param name="forum">The given forum.</param>
		public void Remove(Forum forum) {
			this.Log(EventType.Debug, String.Format("The forum '{0}' ({1}), is being removed.", forum.Name, forum.Id));
			Query searchQuery = this.CreateIdSearchQuery(forum);
			this.Remove(searchQuery);
		}

		/// <summary>
		/// Remove the given post from the index.
		/// </summary>
		/// <param name="post">The given post.</param>
		public void Remove(Post post) {
			using (IndexWriter writer = this.GetWriter()) {
				this.Remove(post, writer);
				writer.Close();
			}
		}

		private void Remove(Post post, IndexWriter writer) {
			this.Log(EventType.Debug, String.Format("The post '{0}' ({1}), is being removed.", post.Subject, post.Id));
			Query searchQuery = this.CreateIdSearchQuery(post);
			this.Remove(searchQuery, writer);
		}

		/// <summary>
		/// Remove the documents of a given topic.
		/// </summary>
		/// <param name="topic">The given topic.</param>
		public void Remove(Topic topic) {
			this.Log(EventType.Debug, String.Format("The topic '{0}' ({1}), is being removed.", topic.Title, topic.Id));
			Query searchQuery = this.CreateIdSearchQuery(topic);
			this.Remove(searchQuery);
		}

		/// <summary>
		/// Remove any document that matches the query!
		/// </summary>
		/// <param name="query"></param>
		private void Remove(Query query) {
			using (IndexWriter writer = this.GetWriter()) {
				this.Remove(query, writer);
				writer.Close();
			}
		}

		private void Remove(Query query, IndexWriter writer) {
			writer.DeleteDocuments(query);
		}
		#endregion

		#region Private methods
		private void Initialize() {
			if (!initialized) {
				// TODO: Do we really need this? Why is initialize called during App init ?!?!?!?
				try {
					//String indexFolder = Path.Combine(this.IndexRoot, "Indexes");
					//if (!System.IO.Directory.Exists(indexFolder)) {
					//    System.IO.Directory.CreateDirectory(indexFolder);
					//}

					this.privateDirectory = this.dirResolver.GetDirectory();
					this.privateAnalyzer = new StandardAnalyzer(Version.LUCENE_30);
					//this.privateAnalyzer = new StandardAnalyzer(Version.LUCENE_29);
					initialized = true;
				}
				catch { }
			}
		}

		//private IndexSearcher Searcher {
		//    get {
		//        return new IndexSearcher(this.GetWriter().GetReader());
		//    }
		//}

		/// <summary>
		/// Only call this method in a lock!
		/// </summary>
		private IndexWriter GetWriter() {
			//			if (writer == null) {
			IndexWriter writer = null;
			//if (IndexWriter.IsLocked(this.Directory)) {
			//    //Log.Error("Something left a lock in the index folder: deleting it");
			//    IndexWriter.Unlock(this.Directory);
			//    //Log.Info("Lock Deleted... can proceed");
			//}
			Int32 attempts = 0;
			Boolean tryClear = false;
			while (writer == null) {
				try {
					// Let's create the writer!
					writer = new IndexWriter(this.Directory, this.Analyzer, !IndexReader.IndexExists(this.Directory), IndexWriter.MaxFieldLength.UNLIMITED);
				}
				catch (LockObtainFailedException ex) {
					if (attempts > 1) {
						throw ex;
					}
					tryClear = true;
				}
				try {
					if (tryClear) {
						tryClear = false;
						this.Directory.ClearLock("write.lock");

						if (IndexWriter.IsLocked(this.Directory)) {
							//Log.Error("Something left a lock in the index folder: deleting it");
							IndexWriter.Unlock(this.Directory);
							//Log.Info("Lock Deleted... can proceed");
						}
					}
				}
				catch (LockObtainFailedException) { }
				attempts++;
			}
			writer.SetMergePolicy(new LogDocMergePolicy(writer));
			writer.MergeFactor = 5;
			//			}
			return writer;
		}

		/// <summary>
		/// Create a Lucene Document with the fields we need for searching.
		/// </summary>
		/// <param name="post">The post that needs indexing.</param>
		/// <returns>The newly created document.</returns>
		private Document CreateDocument(Post post) {
			Document doc = new Document();

			Field postIdField = new Field(FieldNames.PostId,
									NumericUtils.IntToPrefixCoded(post.Id),
									Field.Store.YES,
									Field.Index.NOT_ANALYZED,
									Field.TermVector.NO);

			Field topicIdField = new Field(FieldNames.TopicId,
									NumericUtils.IntToPrefixCoded(post.Topic.Id),
									Field.Store.YES,
									Field.Index.NOT_ANALYZED,
									Field.TermVector.NO);

			Field forumIdField = new Field(FieldNames.ForumId,
								NumericUtils.IntToPrefixCoded(post.Topic.Forum.Id),
								Field.Store.YES,
								Field.Index.NOT_ANALYZED,
								Field.TermVector.NO);

			Field titleField = new Field(FieldNames.Title,
								post.Subject,
								Field.Store.YES,
								Field.Index.ANALYZED,
								Field.TermVector.YES);
			// Boost the title
			titleField.Boost = (float)this.config.TitleWeight / 100f;

			Field bodyField = new Field(FieldNames.Body,
								post.Body,
								Field.Store.NO,
								Field.Index.ANALYZED,
								Field.TermVector.YES);

			Field postedField = new Field(FieldNames.Posted,
								DateTools.DateToString(post.Posted, DateTools.Resolution.MINUTE),
								Field.Store.NO,
								Field.Index.ANALYZED,
								Field.TermVector.NO);

			// TODO: Right !?!?!?	
			Field authorField = new Field(FieldNames.Author,
								post.AuthorName,
								Field.Store.YES,
								Field.Index.ANALYZED,
								Field.TermVector.NO);

			doc.Add(postIdField);
			doc.Add(topicIdField);
			doc.Add(forumIdField);
			doc.Add(titleField);
			doc.Add(bodyField);
			doc.Add(postedField);
			doc.Add(authorField);

			// TODO: 1 ???
			if (post.Position == 0) {
				if (post.Topic.Announcement) {
					doc.Boost = (float)this.config.AnnouncementWeight / 100f;
				}
				else if (post.Topic.Sticky) {
					doc.Boost = (float)this.config.StickyWeight / 100f;
				}
				else {
					doc.Boost = (float)this.config.TopicWeight / 100f;
				}
			}

			return doc;
		}
		#endregion

		#region Index methods
		/// <summary>
		/// Index a post.
		/// </summary>
		/// <param name="post">The post that needs to be indexed.</param>
		public void Index(Post post) {
			this.AddDocuments(new[] { post });
		}

		public void BulkIndex(IEnumerable<Post> posts) {
			this.AddDocuments(posts);
		}

		/// <summary>
		/// Index a topic.
		/// </summary>
		/// <param name="topic">The topic that needs to be indexed.</param>
		public void Index(Topic topic) {
			if (topic.Flag != TopicFlag.Moved) {
				this.AddDocuments(topic.Posts);
			}
		}
		#endregion

		#region Specific queries
		/// <summary>
		/// Create the query to get the document matching a given post.
		/// </summary>
		/// <param name="post">The specific post.</param>
		/// <returns>The query needed to get the document matching the given post.</returns>
		private Query CreateIdSearchQuery(Post post) {
			return new TermQuery(new Term(FieldNames.PostId, NumericUtils.IntToPrefixCoded(post.Id)));
		}

		/// <summary>
		/// Create the query to get documents from a given topic.
		/// </summary>
		/// <param name="topic">The specific topic.</param>
		/// <returns>The query needed to get the documents of the given topic.</returns>
		private Query CreateIdSearchQuery(Topic topic) {
			return new TermQuery(new Term(FieldNames.TopicId, NumericUtils.IntToPrefixCoded(topic.Id)));
		}

		/// <summary>
		/// Create the query to get documents from a given forum.
		/// </summary>
		/// <param name="forum">The specific forum.</param>
		/// <returns>The query needed to get the documents of the given forum</returns>
		private Query CreateIdSearchQuery(Forum forum) {
			return new TermQuery(new Term(FieldNames.ForumId, NumericUtils.IntToPrefixCoded(forum.Id)));
		}
		#endregion

		/// <summary>
		/// Add post(s) to the index.
		/// </summary>
		/// <param name="posts">The list of posts that needs to be added in the index.</param>
		private void AddDocuments(IEnumerable<Post> posts) {
			this.Log(EventType.Debug, "Time to index one or more posts.");
			using (IndexWriter writer = this.GetWriter()) {
				foreach (Post post in posts) {
					this.Remove(post, writer);
					try {
						writer.AddDocument(this.CreateDocument(post));
					}
					catch { /* TODO: */ }
				}

				// TODO: ?!?!?!?
				Boolean optimize = true;
				if (optimize) {
					writer.Optimize();
				}
				writer.Close();
			}
		}

		private Analyzer Analyzer {
			get {
				if (!disposing) {
					this.Initialize();
				}
				return this.privateAnalyzer;
			}
		}

		private Directory Directory {
			get {
				if (!disposing) {
					this.Initialize();
				}
				return this.privateDirectory;
			}
		}

		#region End, disposing!
		~Indexer() {
			Dispose();
		}

		public void Dispose() {
			lock (writerLock) {
				if (!disposed) {
					disposing = true;

					var dir = this.Directory;
					if (dir != null) {
						try {
							dir.Close();
						}
						catch (ObjectDisposedException e) {
							//Log.Error("Exception while disposing SearchEngineService", e);
						}
					}

					disposed = true;
				}
			}
			GC.SuppressFinalize(this);
		}
		#endregion
	}
}